跳到主要内容

17.数据获取篇 _ Server Actions(下)

前言​

上篇我们讲了 Server Actions 的基本用法,本篇我们讲讲 Server Actions 的“标准”用法。比如哪些 API 和库是常搭配 Server Actions 使用的?写一个 Server Actions 要注意哪些地方?

我们还会介绍开发 Server Actions 时常遇到的一些问题,比如如何进行乐观更新?如何进行错误处理?如何获取 Cookies、Headers 等数据?如何重定向?等等

让我们开始吧。

Form​

我们先讲讲 Server Actions 处理表单提交时常搭配使用的一些 API。

1. useFormStatus​

首先是 useFormStatus,这是 React 的官方 hook,用于返回表单提交的状态信息。示例代码如下:

'use client'
// app/submit-button.jsx
import { useFormStatus } from 'react-dom'

export function SubmitButton() {
const { pending } = useFormStatus()

return (
<button type="submit" aria-disabled={pending}>
{pending ? 'Adding' : 'Add'}
</button>
)
}
// app/page.jsx
import { SubmitButton } from '@/app/submit-button'

export default async function Home() {
return (
<form action={...}>
<input type="text" name="field-name" />
<SubmitButton />
</form>
)
}

使用的时候要注意:useFormStatus 必须用在 <form> 下的组件内部,就像这段示例代码一样。先建立一个按钮组件,在组件内部调用 useFormStatus,然后 <form> 下引用该组件。不能完全写到一个组件中,像这样写就是错误的:

function Form() {
// 🚩 `pending` will never be true
// useFormStatus does not track the form rendered in this component
const { pending } = useFormStatus();
return <form action={submit}></form>;
}

2. useFormState​

然后是 useFormState,这也是 React 官方 hook,根据表单 action 的结果更新状态。

用在 React 时示例代码如下:

import { useFormState } from "react-dom";

async function increment(previousState, formData) {
return previousState + 1;
}

function StatefulForm({}) {
const [state, formAction] = useFormState(increment, 0);
return (
<form>
{state}
<button formAction={formAction}>Increment</button>
</form>
)
}

用在 Next.js,结合 Server Actions 时,示例代码如下:

'use client'

import { useFormState } from 'react-dom'

export default function Home() {

async function createTodo(prevState, formData) {
return prevState.concat(formData.get('todo'));
}

const [state, formAction] = useFormState(createTodo, [])

return (
<form action={formAction}>
<input type="text" name="todo" />
<button type="submit">Submit</button>
<p>{state.join(',')}</p>
</form>
)
}

3. 实战体会​

现在让我们结合 useFormStatus 和 useFormState,讲解使用 Server Actions 如何处理 form 提交。涉及的目录和文件如下:

app                 
└─ form3
├─ actions.js
├─ form.js
└─ page.js

其中 app/form3/page.js 代码如下:

import { findToDos } from './actions';
import AddToDoForm from './form';

export default async function Page() {
const todos = await findToDos();
return (
<>
<AddToDoForm />
<ul>
{todos.map((todo, i) => <li key={i}>{todo}</li>)}
</ul>
</>
)
}

app/form3/form.js,代码如下:

'use client'

import { useFormState, useFormStatus } from 'react-dom'
import { createToDo } from './actions';

const initialState = {
message: '',
}

function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" aria-disabled={pending}>
{pending ? 'Adding' : 'Add'}
</button>
)
}

export default function AddToDoForm() {
const [state, formAction] = useFormState(createToDo, initialState)

return (
<form action={formAction}>
<input type="text" name="todo" />
<SubmitButton />
<p aria-live="polite" className="sr-only">
{state?.message}
</p>
</form>
)
}

app/form3/actions.js,代码如下:

'use server'

import { revalidatePath } from "next/cache";

const sleep = ms => new Promise(r => setTimeout(r, ms));

let data = ['阅读', '写作', '冥想']

export async function findToDos() {
return data
}

export async function createToDo(prevState, formData) {
await sleep(500)
const todo = formData.get('todo')
data.push(todo)
revalidatePath("/form3");
return {
message: `add ${todo} success!`
}
}

交互效果如下:

actions-6.gif 注意:当使用 useFormState 的时候,对应 Server Action 函数的参数,第一个参数是 prevState,第二个参数是 formData。当使用 useFormStatus 的时候,要写在 form 下的单独的组件中。使用的时候,注意这两点就行。

值得一提的是:

<p aria-live="polite" className="sr-only">
{state?.message}
</p>

aria-live表示这是一个 ARIA 标签,用于礼貌通知用户发生了变更。"sr-only"表示这是一个只用于 screen reader 的内容。因为我们并没有设置 sr-only 的样式,所以在页面中显露了出来,按理说要加一个如下的样式:

.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

简单的来说,这段内容在屏幕上并不应该显示出来。返回这个信息是用于通知不能像正常人看到屏幕内容、需要借助屏幕阅读器工具的人,任务创建成功。

Server Actions​

接下来讲讲写 Server Actions 有哪些注意要点。简单来说,要注意:

  1. 获取提交的数据
  2. 进行数据校验和错误处理
  3. 重新验证数据
  4. 错误处理

1. 获取数据​

如果使用 form action 这种最基本的形式,Server Action 函数第一个参数就是 formData:

export default function Page() {
async function createInvoice(formData) {
'use server'

const rawFormData = {
customerId: formData.get('customerId')
}

// mutate data
// revalidate cache
}

return <form action={createInvoice}>...</form>
}

如果使用 form action + useFormState 这种形式,Server Actions 函数第一个参数是 prevState,第二个参数是 formData:

'use client'

import { useFormState } from 'react-dom'

export default function Home() {

async function createTodo(prevState, formData) {
return prevState.concat(formData.get('todo'));
}

const [state, formAction] = useFormState(createTodo, [])

return (
<form action={formAction}>
<input type="text" name="todo" />
<button type="submit">Submit</button>
<p>{state.join(',')}</p>
</form>
)
}

如果是直接调用,那看调用的时候是怎么传入的,比如上篇举的事件调用的例子:

'use client'

import { createToDoDirectly } from './actions';

export default function Button({children}) {
return <button onClick={async () => {
const data = await createToDoDirectly('运动')
alert(JSON.stringify(data))
}}>{children}</button>
}
'use server'

export async function createToDoDirectly(value) {
const form = new FormData()
form.append("todo", value);
return createToDo(form)
}

2. 表单验证​

Next.js 推荐基本的表单验证使用 HTML 元素自带的验证如 required、type="email"等。

对于更高阶的服务端数据验证,可以使用 zod 这样的 schema 验证库来验证表单数据的结构:

'use server'

import { z } from 'zod'

const schema = z.object({
email: z.string({
invalid_type_error: 'Invalid Email',
}),
})

export default async function createsUser(formData) {
const validatedFields = schema.safeParse({
email: formData.get('email'),
})

// Return early if the form data is invalid
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}

// Mutate data
}

3. 重新验证数据​

Server Action 修改数据后,一定要注意重新验证数据,否则数据不会及时更新。

使用 revalidatePath:

'use server'

import { revalidatePath } from 'next/cache'

export async function createPost() {
try {
// ...
} catch (error) {
// ...
}

revalidatePath('/posts')
}

使用 revalidateTag:

'use server'

import { revalidateTag } from 'next/cache'

export async function createPost() {
try {
// ...
} catch (error) {
// ...
}

revalidateTag('posts')
}

4. 错误处理​

一种是返回错误信息。举个例子,当一个条目创建失败,返回错误信息:

'use server'
// app/actions.js
export async function createTodo(prevState, formData) {
try {
await createItem(formData.get('todo'))
return revalidatePath('/')
} catch (e) {
return { message: 'Failed to create' }
}
}

在客户端组件中,读取这个值并显示错误信息:

'use client'
// app/add-form.jsx
import { useFormState, useFormStatus } from 'react-dom'
import { createTodo } from '@/app/actions'

const initialState = {
message: null,
}

function SubmitButton() {
const { pending } = useFormStatus()

return (
<button type="submit" aria-disabled={pending}>
Add
</button>
)
}

export function AddForm() {
const [state, formAction] = useFormState(createTodo, initialState)

return (
<form action={formAction}>
<label htmlFor="todo">Enter Task</label>
<input type="text" id="todo" name="todo" required />
<SubmitButton />
<p aria-live="polite" className="sr-only">
{state?.message}
</p>
</form>
)
}

一种是抛出错误,会由最近的 error.js 捕获:

'use client'
// error.js
export default function Error() {
return (
<h2>error</h2>
)
}
// page.js
import { useFormState } from 'react-dom'

function AddForm() {
async function serverActionWithError() {
'use server';
throw new Error(`This is error is in the Server Action`);
}

return (
<form action={serverActionWithError}>
<button type="submit">Submit</button>
</form>
)
}

export default AddForm

这样当 Server Action 发生错误的时候,就会展示错误 UI。

乐观更新​

1. useOptimistic​

所谓乐观更新,举个例子,当用户点击一个点赞按钮的时候,传统的做法是等待接口返回成功时再更新 UI。乐观更新是先更新 UI,同时发送数据请求,至于数据请求后的错误处理,则根据自己的需要自定义实现。

React 提供了 useOptimistic hook,这也是官方 hook,基本用法如下:

import { useOptimistic } from 'react';

function AppContainer() {
const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state
// with optimistic value
}
);
}

结合 Server Actions 使用的示例代码如下:

'use client'

import { useOptimistic } from 'react'
import { send } from './actions'

export function Thread({ messages }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [...state, { message: newMessage }]
)

return (
<div>
{optimisticMessages.map((m) => (
<div>{m.message}</div>
))}
<form
action={async (formData) => {
const message = formData.get('message')
addOptimisticMessage(message)
await send(message)
}}
>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
)
}

2. 实战体会​

为了加深对乐观更新的理解,我们来写一个例子。项目目录和文件如下:

app                 
└─ form4
├─ actions.js
├─ form.js
└─ page.js

其中 app/form4/page.js 代码如下:

import { findToDos } from './actions';
import Form from './form';

export default async function Page() {
const todos = await findToDos();
return (
<Form todos={todos} />
)
}

app/form4/form.js,代码如下:

'use client'

import { useOptimistic } from 'react'
import { useFormState } from 'react-dom'
import { createToDo } from './actions';

export default function Form({ todos }) {
const [state, sendFormAction] = useFormState(createToDo, { message: '' })

const [optimistiToDos, addOptimisticTodo] = useOptimistic(
todos.map((i) => ({text: i})),
(state, newTodo) => [
...state,
{
text: newTodo,
sending: true
}
]
);

async function formAction(formData) {
addOptimisticTodo(formData.get("todo"));
await sendFormAction(formData);
}

console.log(optimistiToDos)

return (
<>
<form action={formAction}>
<input type="text" name="todo" />
<button type="submit"> Add </button>
<p aria-live="polite" className="sr-only">
{state?.message}
</p>
</form>
<ul>
{optimistiToDos.map(({text, sending}, i) => <li key={i}>{text}{!!sending && <small> (Sending...)</small>}</li>)}
</ul>
</>
)
}

app/form4/actions.js,代码如下:

'use server'

import { revalidatePath } from "next/cache";

const sleep = ms => new Promise(r => setTimeout(r, ms));

let data = ['阅读', '写作', '冥想']

export async function findToDos() {
return data
}

export async function createToDo(prevState, formData) {
await sleep(2500)
const todo = formData.get('todo')
data.push(todo)
revalidatePath("/form4");
return {
message: `add ${todo} success!`
}
}

交互效果如下:

actions-7.gif

注:乐观更新是一种面向未来的 UI 更新方式。如何在接口错误的时候撤回数据?如果接口实在是太慢了,乐观更新的时候,用户要离开该怎么办?

关于这些更细节的实现问题,欢迎参考 《Next.js v14 实现乐观更新,面向未来的 UI 更新方式,你可以不去做,但你不应该不了解》

常见问题​

1. 如何处理 Cookies ?​

'use server'

import { cookies } from 'next/headers'

export async function exampleAction() {
// Get cookie
const value = cookies().get('name')?.value

// Set cookie
cookies().set('name', 'Delba')

// Delete cookie
cookies().delete('name')
}

2. 如何重定向?​

'use server'

import { redirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'

export async function createPost(id) {
try {
// ...
} catch (error) {
// ...
}

revalidateTag('posts') // Update cached posts
redirect(`/post/${id}`) // Navigate to the new post page
}

参考链接​

  1. Data Fetching: Fetching, Caching, and Revalidating
  2. Data Fetching: Data Fetching Patterns
  3. Data Fetching: Forms and Mutations
  4. Functions: Server Actions
  5. https://makerkit.dev/blog/tutorials/nextjs-server-actions